This repository has no description
1import type { Did } from "@atcute/lexicons/syntax";
2import { createBobbinClient } from "$lib/api/client";
3import { fetchPage } from "$lib/api/pagination";
4import { enrich, countOf, viewerUriOf, type Stats, type LinkDescriptor } from "$lib/api/enrich";
5import { type RepoRecord, type RecordList } from "$lib/api/records";
6import { IdentityCache } from "$lib/api/identity";
7import { didFromUri, rkeyFromUri } from "$lib/api/uri";
8import { toHttpError } from "$lib/api/load";
9import type { SearchPage } from "$lib/api/search";
10import type { BobbinContext } from "$lib/api/client";
11import { type VouchRecord } from "$lib/api/graph";
12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star";
13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string";
14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow";
15import type {
16 RepoCardData,
17 StringCardData,
18 PersonData,
19 VouchData,
20 StarData
21} from "$lib/components/profile/types";
22import type { PageLoad } from "./$types";
23
24const PAGE_LIMIT = 50;
25
26const STAR_COUNT: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "count" };
27const STAR_VIEWER: LinkDescriptor = { source: "sh.tangled.feed.star:subject", type: "viewer" };
28const FOLLOW_STATS: LinkDescriptor[] = [
29 { source: "sh.tangled.graph.follow:subject", type: "count" },
30 { source: "sh.tangled.graph.follow:.repo", type: "count" }
31];
32const FOLLOW_VIEWER: LinkDescriptor = { source: "sh.tangled.graph.follow:subject", type: "viewer" };
33
34const TABS = [
35 "overview",
36 "repos",
37 "starred",
38 "strings",
39 "followers",
40 "following",
41 "vouches"
42] as const;
43type Tab = (typeof TABS)[number];
44
45const normalizeTab = (raw: string | null): Tab =>
46 TABS.includes(raw as Tab) ? (raw as Tab) : "overview";
47
48interface ListItem {
49 uri: string;
50 value: unknown;
51}
52
53const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => {
54 const value = item.value as RepoRecord;
55 return {
56 rkey: rkeyFromUri(item.uri),
57 name: value.name ?? rkeyFromUri(item.uri),
58 repoDid: value.repoDid ?? "",
59 ownerHandle,
60 description: value.description,
61 knot: value.knot,
62 createdAt: value.createdAt
63 };
64};
65
66const resolveRepoCard = (item: ListItem, ownerHandle: string, stats: Stats): RepoCardData => {
67 const repo = toRepoCard(item, ownerHandle);
68 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null };
69 const stars = countOf(stats, repo.repoDid, STAR_COUNT.source);
70 const viewerUri = viewerUriOf(stats, repo.repoDid, STAR_VIEWER.source);
71 return { ...repo, stars, viewerStarRkey: viewerUri ? rkeyFromUri(viewerUri) : viewerUri };
72};
73
74const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => {
75 const value = item.value as ShTangledString.Main;
76 return {
77 rkey: rkeyFromUri(item.uri),
78 ownerHandle,
79 filename: value.filename,
80 description: value.description,
81 createdAt: value.createdAt,
82 lines: value.contents?.split("\n").length ?? 1
83 };
84};
85
86// resolves handles and avatars in input order, pulling follower counts and viewer status from the sidecar without extra requests
87const resolvePeople = async (
88 ctx: BobbinContext,
89 dids: string[],
90 stats: Stats,
91 viewerDid?: string
92): Promise<PersonData[]> => {
93 const cache = new IdentityCache(ctx);
94 const unique = [...new Set(dids)];
95
96 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null)));
97
98 const byDid = new Map<string, PersonData>();
99 unique.forEach((did, index) => {
100 const doc = docs[index];
101 const followers = countOf(stats, did, "sh.tangled.graph.follow:subject");
102 const following = countOf(stats, did, "sh.tangled.graph.follow:.repo");
103 const isSelf = viewerDid === did;
104 const viewerUri = viewerUriOf(stats, did, FOLLOW_VIEWER.source);
105 const viewerFollowRkey = viewerUri ? rkeyFromUri(viewerUri) : viewerUri;
106 byDid.set(
107 did,
108 doc
109 ? {
110 did: doc.did,
111 handle: doc.handle,
112 avatar: doc.avatar,
113 followers,
114 following,
115 isSelf,
116 viewerFollowRkey
117 }
118 : { did, handle: did, followers, following, isSelf, viewerFollowRkey }
119 );
120 });
121 return unique.map((did) => byDid.get(did) as PersonData);
122};
123
124const resolveVouches = async (
125 ctx: BobbinContext,
126 items: ListItem[],
127 direction: "incoming" | "outgoing"
128): Promise<VouchData[]> => {
129 const cache = new IdentityCache(ctx);
130 return Promise.all(
131 items.map(async (item): Promise<VouchData> => {
132 const value = item.value as VouchRecord;
133 const otherDid = direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri);
134 const doc = await cache.resolve(otherDid).catch(() => null);
135 return {
136 uri: item.uri,
137 did: otherDid,
138 handle: doc?.handle ?? otherDid,
139 avatar: doc?.avatar,
140 kind: value.kind === "denounce" ? "denounce" : "vouch",
141 direction,
142 reason: value.reason,
143 createdAt: value.createdAt
144 };
145 })
146 );
147};
148
149const resolveStars = async (
150 ctx: BobbinContext,
151 items: ListItem[],
152 viewerDid?: string
153): Promise<StarData[]> => {
154 const cache = new IdentityCache(ctx);
155 const repoDids = [
156 ...new Set(
157 items
158 .map((item) => (item.value as ShTangledFeedStar.Main).subject)
159 .flatMap((s) => (s && "did" in s && s.did ? [s.did] : []))
160 )
161 ];
162 const enriched =
163 repoDids.length > 0
164 ? await enrich<RecordList<RepoRecord>>(ctx, {
165 xrpc: "sh.tangled.repo.getReposByRepoDids",
166 params: { dids: repoDids },
167 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT],
168 ...(viewerDid ? { viewer: viewerDid } : {})
169 })
170 : { output: { items: [] }, stats: {} as Stats };
171 const reposByDid = new Map(
172 enriched.output.items.map((item) => [(item.value as RepoRecord).repoDid, item])
173 );
174 const resolved = await Promise.all(
175 items.map(async (item): Promise<StarData | null> => {
176 const value = item.value as ShTangledFeedStar.Main;
177 const subject = value.subject;
178 if (subject && "did" in subject && subject.did) {
179 const repo = reposByDid.get(subject.did);
180 if (!repo) return null;
181 const ownerDid = didFromUri(repo.uri);
182 const owner = await cache.resolve(ownerDid).catch(() => null);
183 return {
184 kind: "repo",
185 uri: item.uri,
186 createdAt: value.createdAt,
187 repo: resolveRepoCard(repo, owner?.handle ?? ownerDid, enriched.stats)
188 };
189 }
190 if (subject && "uri" in subject && subject.uri) {
191 const ownerDid = didFromUri(subject.uri);
192 const owner = await cache.resolve(ownerDid).catch(() => null);
193 return {
194 kind: "string",
195 uri: item.uri,
196 createdAt: value.createdAt,
197 ownerHandle: owner?.handle ?? ownerDid,
198 rkey: rkeyFromUri(subject.uri)
199 };
200 }
201 return null;
202 })
203 );
204 return resolved.filter((star): star is StarData => star !== null);
205};
206
207export const load: PageLoad = async (event) => {
208 const parent = await event.parent();
209 const tab = normalizeTab(event.url.searchParams.get("tab"));
210
211 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } };
212
213 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch });
214 const did = parent.identity.did as Did;
215 const handle = parent.identity.handle;
216
217 try {
218 switch (tab) {
219 case "repos": {
220 const q = event.url.searchParams.get("q")?.trim();
221 const viewerDid = parent.auth?.did;
222 const viewerEnrich = viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT];
223 if (!q) {
224 const enriched = await enrich<RecordList<RepoRecord>>(ctx, {
225 xrpc: "sh.tangled.repo.listRepos",
226 params: { subject: did, limit: PAGE_LIMIT },
227 enrich: viewerEnrich,
228 ...(viewerDid ? { viewer: viewerDid } : {})
229 });
230 return {
231 tab,
232 repos: enriched.output.items.map((item) =>
233 resolveRepoCard(item, handle, enriched.stats)
234 )
235 };
236 }
237 const enriched = await enrich<SearchPage>(ctx, {
238 xrpc: "sh.tangled.search.query",
239 params: { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT },
240 enrich: viewerEnrich,
241 ...(viewerDid ? { viewer: viewerDid } : {})
242 });
243 return {
244 tab,
245 repos: enriched.output.hits.map((item) => resolveRepoCard(item, handle, enriched.stats))
246 };
247 }
248 case "strings": {
249 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", {
250 subject: did,
251 limit: PAGE_LIMIT
252 });
253 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) };
254 }
255 case "followers": {
256 const viewerDid = parent.auth?.did;
257 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, {
258 xrpc: "sh.tangled.graph.listFollows",
259 params: { subject: did, limit: PAGE_LIMIT },
260 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS,
261 ...(viewerDid ? { viewer: viewerDid } : {})
262 });
263 const dids = enriched.output.items.map((item) => didFromUri(item.uri));
264 return {
265 tab,
266 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid)
267 };
268 }
269 case "following": {
270 const viewerDid = parent.auth?.did;
271 const enriched = await enrich<RecordList<ShTangledGraphFollow.Main>>(ctx, {
272 xrpc: "sh.tangled.graph.listFollowsBy",
273 params: { subject: did, limit: PAGE_LIMIT },
274 enrich: viewerDid ? [...FOLLOW_STATS, FOLLOW_VIEWER] : FOLLOW_STATS,
275 ...(viewerDid ? { viewer: viewerDid } : {})
276 });
277 const dids = enriched.output.items.map(
278 (item) => (item.value as ShTangledGraphFollow.Main).subject
279 );
280 return {
281 tab,
282 people: await resolvePeople(ctx, dids, enriched.stats, viewerDid)
283 };
284 }
285 case "vouches": {
286 const [incomingPage, outgoingPage] = await Promise.all([
287 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }),
288 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT })
289 ]);
290 const [incoming, outgoing] = await Promise.all([
291 resolveVouches(ctx, incomingPage.items, "incoming"),
292 resolveVouches(ctx, outgoingPage.items, "outgoing")
293 ]);
294 const vouches = [...incoming, ...outgoing].sort(
295 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
296 );
297 return {
298 tab,
299 vouches,
300 isSelf: parent.auth?.did === did,
301 profileHandle: handle
302 };
303 }
304 case "starred": {
305 const page = await fetchPage(ctx, "sh.tangled.feed.listStarsBy", {
306 subject: did,
307 limit: PAGE_LIMIT
308 });
309 return {
310 tab,
311 stars: await resolveStars(ctx, page.items, parent.auth?.did)
312 };
313 }
314 case "overview":
315 default: {
316 const viewerDid = parent.auth?.did;
317 const enriched = await enrich<RecordList<RepoRecord>>(ctx, {
318 xrpc: "sh.tangled.repo.listRepos",
319 params: { subject: did, limit: PAGE_LIMIT },
320 enrich: viewerDid ? [STAR_COUNT, STAR_VIEWER] : [STAR_COUNT],
321 ...(viewerDid ? { viewer: viewerDid } : {})
322 });
323
324 const pinnedKeys = parent.profile?.pinnedRepositories ?? [];
325 const byKey = new Map<string, ListItem>();
326 for (const item of enriched.output.items) {
327 const value = item.value as RepoRecord;
328 if (value.repoDid) byKey.set(value.repoDid, item);
329 byKey.set(item.uri, item);
330 }
331 const pinnedItems = pinnedKeys
332 .map((key) => byKey.get(key))
333 .filter((item): item is ListItem => item !== undefined);
334 const pinned = pinnedItems.map((item) => resolveRepoCard(item, handle, enriched.stats));
335
336 return { tab: "overview" as const, overview: { pinned } };
337 }
338 }
339 } catch (cause) {
340 console.error("Page load error:", cause);
341 toHttpError(cause, "Could not load profile data");
342 }
343};